﻿#if DEBUG
#define PREREAD_STREAM
#endif

using EmperialApps.WeatherSpark.Data;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Blob;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;

namespace EmperialApps.WeatherSpark.Service.Internal {

    /// <summary>Acts as a storage and retrieval system for forecasts, backed by an Azure blob container.</summary>
    internal sealed class ForecastBlobStore : IForecastStore {

        /// <summary>Initializes a new instance of the <see cref="ForecastBlobStore"/> class.</summary>
        public ForecastBlobStore( CloudStorageAccount account, ForecastInfoTableStore forecastInfo, ForecastSource source ) {
            this._source = source;
            this._forecastInfo = forecastInfo;

            // Retrieve forecast storage container.
            var client = account.CreateCloudBlobClient( );
            this._forecastContainer = client.GetContainerReference( source.ContainerName );
            this._forecastContainer.CreateIfNotExists( );
            this._forecastContainer.SetPermissions( new BlobContainerPermissions { PublicAccess = BlobContainerPublicAccessType.Container } );

            // Load all existing saved forecasts.
            int forecastCount = 0;
            foreach( CloudBlockBlob blob in this._forecastContainer.ListBlobs( ) ) {
                TraceEventType.Information.Trace( "Found existing forecast: " + this.BlobTraceName( blob ) );
                var location = Coordinate.Parse( blob.Name );
                if( location != default( Coordinate ) ) {
                    this._forecastCache[location] = new WeakReference( null );
                    ++forecastCount;
                }
            }

            TraceEventType.Information.Trace( "Found {0} {1} forecasts.", forecastCount, source );
        }


        /// <summary>Gets the name of the container used to store forecasts.</summary>
        public string ContainerName {
            get { return this._forecastContainer.Name; }
        }

        /// <summary>Retrieves the storage location of the specified forecast, if it exists.</summary>
        public string GetBlobUrl( Coordinate location ) {
            CloudBlockBlob blob = this.GetBlob( location );

            bool exists =
                   this._forecastCache.ContainsKey( location )
                || this.TryLoadFromBlob( blob );
            return exists
                 ? blob.Uri.ToString( )
                 : "";
        }


        /// <inheritdoc/>
        public bool Save( ForecastEventArgs e ) {
            // If forecast has been updated, save new value.
            ErrorKind error = ClassifyError( e.Error );
            bool success = error == ErrorKind.None;
            if( success ) {
                Forecast forecast = e.Forecast;
                bool hasRedirect = forecast.HasRedirect( ).GetValueOrDefault( );
                Forecast existingForecast = this.Load( forecast.Location, updateCache: true );
                bool unchanged =
                       !hasRedirect
                    && existingForecast != null
                    && forecast.Start == existingForecast.Start
                    && forecast.WindSpeed.Count == existingForecast.WindSpeed.Count
                    && forecast.Temperature.Count == existingForecast.Temperature.Count;

                var info = this._forecastInfo.Get( forecast.Location )
                              ?? new ForecastInfo( forecast.Location );
                if( unchanged ) {
                    TraceEventType.Information.Trace( "Ignoring unchanged {0} forecast: {1}", this._source, forecast );
                    info.ForecastUrl = this.GetBlobUrl( forecast.Location );
                }
                else {
                    TraceEventType.Information.Trace( "Saving {0} {1} forecast: {2}", hasRedirect ? "redirected" : "updated", this._source, forecast );
                    this.CacheForecast( forecast );

                    if( hasRedirect ) {
                        this.SaveToBlob( forecast, e.Location );
                    }
                    else {
                        info.ForecastUrl = this.SaveToBlob( forecast );
                        if( existingForecast == null )
                            info.SetLastUserRequest( DateTimeOffset.Now );
                    }
                }

                // Update forecast info.
                this._source.Update( info, forecast );
                if( !hasRedirect && forecast.Location != e.Location ) {
                    TraceEventType.Information.Trace( "Request for {0} forecast {1} redirected to {2}", this._source, e.Location, forecast.Location );
                    this._source.Redirect( this._forecastInfo, info, e.Location );

                    info.SetLastUserRequest( DateTimeOffset.Now );
                }

                this._forecastInfo.Add( info );

                // Save redirect forecast, if necessary.
                if( forecast.Location != e.Location
                 && this.Load( e.Location, updateCache: false ).HasRedirect( ) == false )
                    this.SaveToBlob( forecast.Redirect( info.ForecastUrl ), e.Location );
            }
            // Otherwise, if forecast is not available, update forecast info.
            else {
                this.ForecastUnavailable( e.Location, error, e.Error.Message );
            }

            return success;
        }

        /// <inheritdoc/>
        public Forecast Load( Coordinate location, object context ) {
            return this.Load( location, updateCache: false );
        }

        /// <inheritdoc/>
        public IEnumerable<Coordinate> GetStoredLocations( ) {
            return this._forecastCache.Keys;
        }


        /// <inheritdoc/>
        public event EventHandler<ForecastEventArgs> DownloadCompleted = delegate { };

        /// <inheritdoc/>
        public void BeginDownload( Coordinate location ) {
            ForecastInfo info = this._forecastInfo.Get( location )
                             ?? this._forecastInfo.Get( location ); // retry to ensure info is completely unavailable

            // If info is not available, add empty info for new request.
            if( info == null ) {
                info = new ForecastInfo( location );
                this._forecastInfo.Add( info );
            }

            if( !this._source.HasSourceUrl( location, info ) ) {
                TraceEventType.Verbose.Trace( "Ignoring {0} forecast {1} without source URL: {2}", this._source, location, info );
                return;
            }

            DateTimeOffset lastRequest = info.GetLastUserRequest( );
            DateTimeOffset downloadThreshold = info.GetNextDownloadThreshold( );
            DateTimeOffset lastRequestLimit = DateTimeOffset.Now - LastRequestLimit;

            // If forecast has not been requested recently and we have not reached the next download threshold, continue waiting.
            bool usedInfrequently = info.HasForecast && lastRequest < lastRequestLimit;
            if( usedInfrequently && DateTimeOffset.Now < downloadThreshold ) {
                TraceEventType.Verbose.Trace( "Delaying {0} forecast {1} download until {2:u}.", this._source, location, downloadThreshold );

                // Send completion event to subscribers.
                Forecast forecast = this.Load( location, null );
                this.DownloadCompleted( this, new ForecastEventArgs( location, forecast ) );
            }
            else {
#if DEBUG
                int retryCount = MaximumRetryCount;
#else
                int retryCount = 0;
#endif
                if( usedInfrequently ) {
                    TraceEventType.Verbose.Trace( "Allowing delayed {0} forecast {1} download after {2:u}.", this._source, location, downloadThreshold );
                    retryCount = MaximumRetryCount;
                }

                // Download latest forecast from source.
                string url = this._source.GetSourceUrl( location, info );
                var state = Pair.Create( location, retryCount );
                this.BeginDownload( url, state );
            }
        }


        /// <inheritdoc/>
        public override string ToString( ) {
            return this._source + " Store";
        }


        #region Private Members

        private const int MaximumRetryCount = 2;
        private static readonly TimeSpan LastRequestLimit = TimeSpan.FromDays( 4 * 7 );
        private static readonly TimeSpan DownloadThresholdDelay = TimeSpan.FromHours( 23.5 );

        private readonly ConcurrentDictionary<Coordinate, WeakReference> _forecastCache = new ConcurrentDictionary<Coordinate, WeakReference>( );
        private readonly ConcurrentBag<MonitoredMemoryStream> _freeSaveBuffers = new ConcurrentBag<MonitoredMemoryStream>( );
        private readonly ConcurrentBag<WebClient> _freeDownloadClients = new ConcurrentBag<WebClient>( );
        private readonly CloudBlobContainer _forecastContainer;
        private readonly ForecastInfoTableStore _forecastInfo;
        private readonly ForecastSource _source;


        private CloudBlockBlob GetBlob( Coordinate location ) {
            string blobName = location.GetName( );
            CloudBlockBlob blob = this._forecastContainer.GetBlockBlobReference( blobName );
            return blob;
        }

        private string BlobTraceName( CloudBlockBlob blob ) {
            return this._source + "/" + blob.Name;
        }

        private Forecast LoadFromBlob( CloudBlockBlob blob ) {
            using( Stream stream = blob.OpenRead( ) )
                return Forecast.Load( stream );
        }

        private bool TryLoadFromBlob( CloudBlockBlob blob ) {
            Forecast forecast;
            return this.TryLoadFromBlob( blob, out forecast );
        }

        private bool TryLoadFromBlob( CloudBlockBlob blob, out Forecast forecast ) {
            try {
                forecast = this.LoadFromBlob( blob );
                this.CacheForecast( forecast );
                return true;
            }
            catch( Exception ex ) {
                string name = this.BlobTraceName( blob );
                if( ex.Message.Contains( "(404)" ) )
                    TraceEventType.Information.Trace( "No forecast stored for " + name );
                else
                    ex.Trace( "Could not load forecast from " + name );

                forecast = null;
                return false;
            }
        }

        private string SaveToBlob( Forecast forecast, Coordinate? location = null ) {
            CloudBlockBlob blob = this.GetBlob( location ?? forecast.Location );
            try {
                MonitoredMemoryStream saveBuffer;
                if( this._freeSaveBuffers.TryTake( out saveBuffer ) )
                    saveBuffer.SetLength( 0 );
                else
                    saveBuffer = new MonitoredMemoryStream( this._source + " blob save buffer" );

                forecast.Save( saveBuffer );
                saveBuffer.SaveToBlob( blob );

                this._freeSaveBuffers.Add( saveBuffer );
            }
            catch( StorageException ex ) {
                if( ex.IsTimeoutException( ) )
                    ex.Trace( "Timeout saving forecast to " + this.BlobTraceName( blob ) );
                else
                    ex.Trace( "Could not save forecast to " + this.BlobTraceName( blob ) );
            }
            catch( IOException ex ) {
                ex.Trace( "Could not save forecast to " + this.BlobTraceName( blob ) );
            }

            return blob.Uri.ToString( );
        }

        private Forecast Load( Coordinate location, bool updateCache ) {
            // Try to retrieve forecast from local cache.
            Forecast forecast;
            bool found = this.TryGetForecast( location, out forecast );
            if( (found && forecast == null) // Reload if forecast exists but has not been loaded,
             || (!found && updateCache) ) { //  or if we should check for forecasts added after cache initialization.
                TraceEventType.Information.Trace( "Lazily loading {0} forecast {1} from cache", this._source, location );
                var blob = GetBlob( location );
                if( this.TryLoadFromBlob( blob, out forecast ) ) {
                    TraceEventType.Verbose.Trace( "Lazily loaded {0} forecast: {1}", this._source, forecast );
                }
                else {
                    WeakReference forecastReference;
                    this._forecastCache.TryRemove( location, out forecastReference );
                }
            }

            return forecast;
        }

        private void CacheForecast( Forecast forecast ) {
            WeakReference forecastReference;
            if( this._forecastCache.TryGetValue( forecast.Location, out forecastReference ) )
                forecastReference.Target = forecast;
            else
                this._forecastCache[forecast.Location] = new WeakReference( forecast );
        }

        private bool TryGetForecast( Coordinate location, out Forecast forecast ) {
            WeakReference forecastReference;
            bool found = this._forecastCache.TryGetValue( location, out forecastReference );
            forecast = forecastReference != null ? (Forecast)forecastReference.Target : null;
            return found;
        }

        private bool NoForecast( Coordinate location ) {
            // Verify that no other forecast source or server instance has been able to save the forecast.
            ForecastInfo info = this._forecastInfo.Get( location );
            string url = info == null ? null : info.ForecastUrl;
            return url == null
                && Load( location, updateCache: true ) == null;
        }

        private void ForecastUnavailable( Coordinate location, ErrorKind error, string errorMessage ) {
            var info = this._forecastInfo.Get( location )
                          ?? new ForecastInfo( location );

            bool persistentError = error == ErrorKind.Persistent;
            this._source.Update( info, ref persistentError );

            bool sourceFailure = persistentError && NoForecast( location );
            if( sourceFailure )
                info.ForecastUrl = string.Format( "-[{0}] {1}", location, errorMessage );

            this._forecastInfo.Add( info );

            if( persistentError )
                StorageAccount.Current.Aggregator.Fallback( info, this._source );
        }


        private void BeginDownload( string url, Pair<Coordinate, int> state ) {
            TraceEventType.Verbose.Trace( "Beginning {0} forecast download: {1} [{2}]", this._source, state, url );

            // Retrieve free download client, or create a new one.
            WebClient downloadClient;
            if( !this._freeDownloadClients.TryTake( out downloadClient ) ) {
                downloadClient = new WebClient( );
                downloadClient.OpenReadCompleted += this.OnDownloadOpenReadCompleted;
            }

            // Begin download.
            downloadClient.OpenReadAsync( new Uri( url ), state );
        }

        private void OnDownloadOpenReadCompleted( object sender, OpenReadCompletedEventArgs e ) {
            Coordinate location;
            int retryCount;
            e.UserState.TryGetValues( out location, out retryCount );
            Coordinate? fallbackLocation =
                this._forecastCache.ContainsKey( location )
                    ? location
                    : default( Coordinate? );

            // Try to load forecast from result stream.
            Forecast forecast = null;
            string streamContent = null;
            Exception error = e.Error
                ?? TryLoadForecast( e.Result, fallbackLocation, out streamContent, out forecast );
            ErrorKind errorKind = ClassifyError( error );

            // Return download client to cache, after result has been processed.
            this._freeDownloadClients.Add( (WebClient)sender );

            // If download succeeded, raise completion event.
            if( errorKind == ErrorKind.None ) {
                TraceEventType.Information.Trace( "Received {0} forecast: {1}", this._source, forecast );

                // 2014-07-14: Notify users of 2.7 client and earlier to upgrade by the end of the year.
                if( this._source == ForecastSource.NOAA ) {
                    forecast = new Forecast(
                        forecast.Location, "Please update to v2.8 or later by 2014-12-31",
                        forecast.Start,
                        forecast.Interval,
                        new Dictionary<ForecastData, System.Collections.ObjectModel.ReadOnlyCollection<double>> {
                            { ForecastData.Temperature, forecast.Temperature },
                            { ForecastData.PrecipitationPotential, forecast.PrecipitationPotential },
                            { ForecastData.SkyCover, forecast.SkyCover },
                            { ForecastData.RelativeHumidity, forecast.RelativeHumidity },
                            { ForecastData.WindSustainedSpeed, forecast.WindSpeed },
                            { ForecastData.WindDirection, forecast.WindDirection },
                        } );
                }

                this.DownloadCompleted( this, new ForecastEventArgs( location, forecast ) );

                // Update download threshold.
                ForecastInfo info = this._forecastInfo.Get( location );
                if( info != null ) {
                    info.SetNextDownloadThreshold( DateTimeOffset.Now + DownloadThresholdDelay );
                    this._forecastInfo.Update( info );
                }
            }
            // If download failed for a known error, raise completion event.
            else if( errorKind == ErrorKind.Persistent ) {
                TraceEventType.Information.Trace( "{0} {1} forecast unavailable: {2}", location, this._source, error );
                this.DownloadCompleted( this, new ForecastEventArgs( location, forecast, error ) );
            }
            // If download failed and we have not exhausted the retry count, attempt download again.
            else if( retryCount < MaximumRetryCount ) {
                TraceEventType.Warning.Trace( "Retrying failed {0} forecast {1} download: {2}", this._source, location, error );

                string url = this._source.GetSourceUrl( location, this._forecastInfo.Get );
                var retryState = Pair.Create( location, 1 + retryCount );
                this.BeginDownload( url, retryState );
            }
            // Otherwise, report failure (if it is not a known source-side error).
            else {
                if( errorKind == ErrorKind.Unexpected
                 || (!string.IsNullOrEmpty( streamContent ) && error.InnerException is EndOfStreamException) ) {
                    if( streamContent != null )
                        error = new IOException( "Could not load forecast from stream:" + Environment.NewLine + streamContent + Environment.NewLine, error );

                    error.Report( "Server Download Failure " + location );
                }
                else {
                    TraceEventType.Verbose.Trace( "Could not load {0} forecast {1} from stream: {2}", this._source, location, streamContent );
                }

                // If download has never succeeded, notify subscriber.
                if( !this._forecastCache.ContainsKey( location ) )
                    this.DownloadCompleted( this, new ForecastEventArgs( location, forecast, error ) );
                // Otherwise, update info.
                else
                    this.ForecastUnavailable( location, errorKind, error.Message );
            }
        }

        private static Exception TryLoadForecast( Stream stream, Coordinate? fallbackLocation, out string streamContent, out Forecast forecast ) {
            Exception error;

            streamContent = null;
            try {
#if PREREAD_STREAM
                // Read full contents of stream.
                using( var reader = new StreamReader( stream ) )
                    streamContent = reader.ReadToEnd( );

                // Try to load forecast from stream contents.
                using( var source = new MemoryStream( System.Text.Encoding.UTF8.GetBytes( streamContent ) ) )
#else
                using( var source = stream )
#endif
                    forecast = Forecast.Load( source, fallbackLocation );

                error = null;
            }
            catch( Exception ex ) {
                forecast = null;
                error = ex;
            }

            return error;
        }

        private ErrorKind ClassifyError( Exception error ) {
            if( error == null )
                return ErrorKind.None;


            if( error is WebException
             && (error.Message.StartsWith( "The remote server returned an error:", StringComparison.Ordinal )
              || error.Message.EndsWith( "The connection was closed unexpectedly.", StringComparison.Ordinal )) )
                return ErrorKind.Transient;

            if( error.InnerException is System.ObjectDisposedException
             || error.InnerException is System.IO.EndOfStreamException
             || error.InnerException is System.Net.Sockets.SocketException )
                return ErrorKind.Transient;

            if( error.InnerException is System.IO.IOException
             && error.InnerException.Message.IndexOf( "forcibly closed by the remote host", StringComparison.Ordinal ) > 0 )
                return ErrorKind.Transient;

            if( error.InnerException is System.Xml.XmlException
             && error.InnerException.Message.StartsWith( "For security reasons DTD is prohibited", StringComparison.Ordinal ) )
                return ErrorKind.Transient;


            if( error.InnerException is System.Xml.XmlException
             && error.InnerException.Message.StartsWith( "'=' is an unexpected token. The expected token is ';'", StringComparison.Ordinal ) )
                return ErrorKind.Persistent;

            if( error.InnerException == null
             && error.Message.Equals( "File did not contain any temperature data." ) )
                return ErrorKind.Persistent;


            return ErrorKind.Unexpected;
        }

        private enum ErrorKind {
            None = 0,
            Unexpected,
            Transient,
            Persistent,
        };

        #endregion
    }

}
